iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 19
1
Modern Web

JavaScript 之旅系列 第 19 篇

JavaScript 之旅 (19):String.prototype.matchAll()

  • 分享至 

  • xImage
  •  

本篇介紹 ES2020 (ES11) 提供的 String.prototype.matchAll()。

過去的 RegExp

若將一個字串使用的 RegExp (regular expression,正規表達式,正規表示式) 設定了 sticky 或 global flag,則可能會有多個 capture groups,常見的情境會想迭代所有 match 到的結果,可能會有幾種作法:

  • String.prototype.match()
  • RegExp.prototype.exec()
  • String.prototype.replace()

分別來介紹過去的這些作法有哪些缺點。

String.prototype.match()

若在 String.prototype.match() 使用的 RegExp 沒有設定 global flag,就只能取得第一個 capture group、index、input 和 groups 這些資訊:

let string = 'JavaScript ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/;

let result = string.match(pattern);
console.log(result);
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]

若有 global flag 不是取得所有 capture group 和其他資訊,而是只能取得所有 match 到的字串:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let result = string.match(pattern);
console.log(result);
// ["ES7", "ES8", "ES9"]

這樣根本不夠用!這就是 String.prototype.match() 可惜的地方。

如果你只想取得所有 match 到的字串,那 String.prototype.match() 很好用,若你想要的是詳細一點的資訊,例如:capture group,String.prototype.match() 是無法滿足你的。

那改用 RegExp.prototype.exec() 呢?

RegExp.prototype.exec()

若在 RegExp.prototype.exec() 使用的 RegExp 有設定 global 或 sticky flag,在執行 RegExp.prototype.exec() 後,會在該 RegExp 物件儲存前一個 match 的 lastIndex (即上次最後 match 的字串的最後一個字元在原字串中的 index 為何,用於下一次 match 開始的 index)。

所以只要重複執行幾次 RegExp.prototype.exec(),就能一直取得 match 的結果,即取得第一個 capture group、index、input 和 groups 這些資訊。

直到 match 的結果為 null 時,代表已經找不到 match 的字串,此時會將 RegExp 物件的 lastIndex 設為 0,代表之後執行 RegExp.prototype.exec() 會重頭開始 match 字串。

用剛剛的範例來舉例:match 到幾個字串就要跑幾次,每次都會更新 RegExp 物件的 lastIndex:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

console.log(pattern.lastIndex);
// 0

console.log(pattern.exec(string));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

console.log(pattern.exec(string));
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 7

console.log(pattern.exec(string));
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 11

console.log(pattern.exec(string));
// null
console.log(pattern.lastIndex);
// 0

若 RegExp.prototype.exec() 回傳為 null,且 RegExp 物件的 lastIndex 為 0 時,你再次執行 RegExp.prototype.exec() 就會重頭開始 match 字串:

console.log(pattern.exec(string));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

看到上面手動一步一步執行 RegExp.prototype.exec() 感到累嗎?用迴圈改寫一下:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;

while (match = pattern.exec(string)) {
  console.log(match);
}
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]

舒服多了!

若要保存每次執行 RegExp.prototype.exec() 回傳的 match 結果,可能會這樣寫:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;
let matches = [];

while (match = pattern.exec(string)) {
  matches.push(match);
}

console.log(matches);
// [
//   ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ]

到這邊你覺得 RegExp.prototype.exec() 還行嗎?其實有一些小缺點!

RegExp.prototype.exec() 的小缺點

小缺點如下:

  1. 儲存的 match 結果變數是多餘的
  2. 執行 RegExp.prototype.exec() 會改變 RegExp 物件的 lastIndex

先來說第一個小缺點:為了取得每次 RegExp.prototype.exec() 的 match 結果,且因需要設定迴圈的中止條件,要將 match 結果存在一個變數,這個變數宣告是為了 RegExp.prototype.exec() 的行為而建立的變數 (即下面的 match 變數):

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let match;

while (match = pattern.exec(string)) {
	console.log(match);
}

逼不得已啊...。那能改用 for-of 嗎?這樣就不會多宣告變數啦!

RegExp.prototype.exec() 是不可能做到的,而本篇要介紹的 String.prototype.matchAll() 就能解決這個問題,後面會提到。

接著來說第二個小缺點:執行 RegExp.prototype.exec() 會改變 RegExp 物件的 lastIndex。

這看似沒什麼問題啊?其實問題會發生在不懂 RegExp.prototype.exec() 的人。

假設 RegExp pattern 是共用的,需要讓很多字串 match (此範例為 string1 和 string2 ):

  • 第一次對 string1 使用 RegExp.prototype.exec()
  • 第二次對 string2 使用 RegExp.prototype.exec()
let string1 = 'ES7 ES8 ES9 ECMAScript';
let string2 = 'ES10 ES11 ECMAScript';
let pattern = /(ES(\d+))/g;

console.log(pattern.lastIndex);
// 0

console.log(pattern.exec(string1));
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 3

console.log(pattern.exec(string2));
// ["ES11", "ES11", "11", index: 5, input: "ES10 ES11 ECMAScript", groups: undefined]
console.log(pattern.lastIndex);
// 9

發現問題了嗎?不同字串共用的 RegExp pattern 會因為被修改了 lastIndex,而造成拿到的結果不符合你的預期,string2 看起來是第一次用該 RegExp pattern 來 match 字串,但卻拿到第二次才會被 match 的字串 (即 ES11,原本以為會拿到 ES10 )。

如果你熟悉 RegExp.prototype.exec(),這個問題根本不會發生,但過去還是新手的我就採過這個雷 XD

String.prototype.replace()

有些情境需要透過 String.prototype.replace() 和 RegExp 來將某些字串取代成其他內容。

例如:將 ES7 轉成 ES2016,ES8 轉成 ES2017,ES9 轉成 ES2018,只有前綴 ES 後面加上數字字元才能轉換:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let newString = string.replace(pattern, function(matched, position1, position2) {
  const version = position2;
  return `ES${2009 + Number(version)}`;
});

console.log(newString);
// ES2016 ES2017 ES2018 ECMAScript

註:String.prototype.replace() 的第二個參數可以是字串,或是 callback function,而 callback function 會有多個參數,包括:matched (match 到的字串)、positionN (第幾個 capture group)、index (match 到字元的 index) 和 input (正在 match 的整個字串)。

string.replace(pattern, function(matched, position1, ...positionN, index, input) {
  // ...
});

若要讓 String.prototype.replace() 的行為很像 RegExp.prototype.exec() 回傳的 match 結果,可以這樣寫:

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;
let matches = [];

let newString = string.replace(pattern, function() {
  const match = [...arguments].slice(0, -2);
  match.input = arguments[arguments.length - 1];
  match.index = arguments[arguments.length - 2];
  matches.push(match);

  return `ES${2009 + Number(match[2])}`;
});

console.log(newString);
// ES2016 ES2017 ES2018 ECMAScript

console.log(matches);
// [
//   ["ES7", "ES7", "7", input: "ES7 ES8 ES9 ECMAScript", index: 0],
//   ["ES8", "ES8", "8", input: "ES7 ES8 ES9 ECMAScript", index: 4],
//   ["ES9", "ES9", "9", input: "ES7 ES8 ES9 ECMAScript", index: 8]
// ]

但太麻煩了...

現代的 String.prototype.matchAll()

let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let matches = string.matchAll(pattern);
console.log(matches);
// RegExpStringIterator {}

for (const match of matches) {
  console.log(match);
  console.log(`lastIndex: ${pattern.lastIndex}`);
}
// ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
// ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
// ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// lastIndex: 0
let string = 'ES7 ES8 ES9 ECMAScript';
let pattern = /(ES(\d+))/g;

let matches = [...string.matchAll(pattern)];
console.log(matches);
// [
//   ["ES7", "ES7", "7", index: 0, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES8", "ES8", "8", index: 4, input: "ES7 ES8 ES9 ECMAScript", groups: undefined],
//   ["ES9", "ES9", "9", index: 8, input: "ES7 ES8 ES9 ECMAScript", groups: undefined]
// ]

資料來源


上一篇
JavaScript 之旅 (18):Array method - flat & flatMap
下一篇
JavaScript 之旅 (20):Promise.allSettled()
系列文
JavaScript 之旅 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言